import execa, { ExecaChildProcess } from 'execa';
import fs from 'fs/promises';
import tmp from 'tmp';
import yargs from 'yargs';

type GithubSource = {
  org: string;
  repo: string;
} & ({ tag: string } | { ref: string });

export function getDescription(source: GithubSource): string {
  if ('tag' in source) {
    return `${source.org}/${source.repo}#${source.tag}`;
  } else if ('ref' in source) {
    return `${source.org}/${source.repo}#${source.ref}`;
  } else {
    throw new Error('Invalid GithubSource, must have either tag or ref');
  }
}

export type VendorConfig = GithubSource & {
  targetDir: string;
  removeFiles: string[];
  /** Git commit range with patches */
  cherryPick: {
    start: string;
    end: string;
  } | null;
};

function isErrorExists(e: NodeJS.ErrnoException): boolean {
  return e.code === 'EEXIST';
}

async function gitCommit({ path, title, message }: { path: string; title: string; message: string }): Promise<void> {
  await execa('git', ['add', path]);
  await execa('git', ['commit', path, '-m', title, '-m', message, '--no-verify'], {
    stdio: 'inherit',
  });
}

async function gitCherryPick(revRange: { start: string; end: string }): Promise<void> {
  const range = `${revRange.start}^..${revRange.end}`;
  await execa('git', ['cherry-pick', range], {
    stdio: 'inherit',
  });
}

async function removeDevAndTestFiles(removePaths: string[], targetDir: string): Promise<void> {
  for (const path of removePaths) {
    console.log(`Removing dev/test file: ${path}`);
    const fullPath = `${targetDir}/${path}`;
    await fs.rm(fullPath, { recursive: true, force: true });
  }
  await gitCommit({
    path: targetDir,
    title: 'chore: remove dev and test files',
    message: 'This commit removes unnecessary development and test files from the vendor directory.',
  });
}

function getUrl(lib: GithubSource): string {
  if ('tag' in lib) {
    const { org, repo, tag } = lib;
    return `https://github.com/${org}/${repo}/archive/refs/tags/${tag}.tar.gz`;
  }
  if ('ref' in lib) {
    const { org, repo, ref } = lib;
    return `https://github.com/${org}/${repo}/tarball/${ref}`;
  }
  throw new Error('Unsupported lib');
}

function getArchivePath(lib: GithubSource): string {
  return tmp.fileSync({ postfix: `-${lib.repo}.tar.gz` }).name;
}

async function fetchArchive(lib: GithubSource, outfile: string): Promise<void> {
  try {
    const result = await fs.stat(outfile);
    if (result.size > 0) {
      console.log(`Archive already exists: ${outfile}`);
      return;
    }
  } catch (e) {}
  const url = getUrl(lib);
  const result = await fetch(url);
  if (!result.ok) {
    throw new Error(`Failed to fetch ${url}: ${result.status} ${result.statusText}`);
  }
  await fs.writeFile(outfile, Buffer.from(await result.arrayBuffer()));
}

function pipe(cmd: ExecaChildProcess): ExecaChildProcess {
  cmd.stdout?.pipe(process.stdout);
  cmd.stderr?.pipe(process.stderr);
  return cmd;
}

async function extractArchive(archivePath: string, targetDir: string): Promise<void> {
  try {
    await fs.mkdir(targetDir, { recursive: true });
  } catch (e) {
    if (!isErrorExists(e)) {
      throw e;
    }
  }
  await pipe(execa('tar', ['-C', targetDir, '--strip-components', '1', '-xzf', archivePath]));
}

async function cmdVendor(
  cfgs: VendorConfig[],
  opts: {
    removeFiles: boolean;
    cherryPick: boolean;
  }
) {
  for (const cfg of cfgs) {
    const desc = getDescription(cfg);
    const archivePath = getArchivePath(cfg);
    await fetchArchive(cfg, archivePath);
    await extractArchive(archivePath, cfg.targetDir);
    await gitCommit({
      path: cfg.targetDir,
      title: `chore: vendor ${desc}`,
      message: `This commit was generated by the vendor-github-repo script.`,
    });

    if (cfg.removeFiles.length === 0) {
      console.log('No files to remove for', cfg.repo);
    } else if (opts.removeFiles) {
      await removeDevAndTestFiles(cfg.removeFiles, cfg.targetDir);
    } else {
      console.log(`Skipping removal of dev/test files for ${cfg.repo}, use --removeFiles to enable.`);
    }

    if (cfg.cherryPick === null) {
      console.log(`No patch set defined for ${cfg.repo}, skipping rebase.`);
    } else if (opts.removeFiles) {
      await gitCherryPick(cfg.cherryPick);
    } else {
      console.log(`Skipping cherry-pick for ${cfg.repo}, use --cherryPick to enable.`);
    }
  }
}

const vendorConfigs: VendorConfig[] = [
  {
    org: 'babylonlabs-io',
    repo: 'btc-staking-ts',
    tag: 'v1.0.3',
    targetDir: 'modules/babylonlabs-io-btc-staking-ts',
    removeFiles: [
      '.eslintrc.json',
      '.github/',
      '.husky/',
      '.npmrc',
      '.nvmrc',
      '.prettierignore',
      '.prettierrc.json',
      'docs/',
      'tests/',
      'README.md',
    ],
    cherryPick: {
      start: '8b8261b8b639d09cbe1223615797c18a2788cd89',
      end: '06110dd3e892df326261cd79ead158c28370add7',
    },
  },
  {
    org: 'babylonlabs-io',
    repo: 'btc-staking-ts',
    tag: 'v2.3.4',
    targetDir: 'modules/babylonlabs-io-btc-staking-ts',
    removeFiles: [
      '.eslintrc.json',
      '.github/',
      '.husky/',
      '.npmrc',
      '.nvmrc',
      '.prettierignore',
      '.prettierrc.json',
      'docs/',
      'tests/',
      '.releaserc.json',
      '.commitlint.config.cjs',
      'README.md',
    ],
    cherryPick: {
      start: '161a937c4303d8273922ebfd04640d2391aca246',
      end: '8d84d9b02af73d7c216d87aceca3dec0baabfecf',
    },
  },
  {
    org: 'babylonlabs-io',
    repo: 'btc-staking-ts',
    tag: 'v2.5.7',
    targetDir: 'modules/babylonlabs-io-btc-staking-ts',
    removeFiles: [
      '.eslintrc.json',
      '.github/',
      '.husky/',
      '.npmrc',
      '.nvmrc',
      '.prettierignore',
      '.prettierrc.json',
      'docs/',
      'tests/',
      '.releaserc.json',
      '.commitlint.config.cjs',
      'README.md',
    ],
    cherryPick: {
      start: '161a937c4303d8273922ebfd04640d2391aca246',
      end: '8d84d9b02af73d7c216d87aceca3dec0baabfecf',
    },
  },
];

function getMatches(name: string, version: string | undefined): VendorConfig[] {
  const matches = vendorConfigs.filter((cfg) => {
    if (name !== cfg.repo) {
      return false;
    }
    if ('tag' in cfg && version !== undefined) {
      return cfg.tag === version;
    }
    return true;
  });
  if (matches.length === 0) {
    throw new Error(`no such vendor config ${name} version ${version}`);
  }
  if (matches.length > 1) {
    throw new Error(`ambiguous vendor config ${name}`);
  }
  return matches;
}

const optName = {
  type: 'string',
  demand: true,
  describe: 'Name of the vendor config to use, e.g. btc-staking-ts',
} as const;

const optVersion = {
  type: 'string',
  describe: 'Version of the vendor config to use, e.g. v2.3.4',
} as const;

yargs
  .command({
    command: 'vendor',
    describe: 'Vendor a github repo',
    builder(a) {
      return a.options({
        name: optName,
        pkgVersion: optVersion,
        removeFiles: {
          type: 'boolean',
          default: true,
          describe: 'Remove dev/test files after extracting the archive',
        },
        cherryPick: {
          type: 'boolean',
          default: true,
          describe: 'Apply cherry-pick patches after extracting the archive',
        },
      });
    },
    async handler(a) {
      await cmdVendor(getMatches(a.name, a.pkgVersion), a);
    },
  })
  .help()
  .strict()
  .demandCommand().argv;
